#import "template.typ": project
#import "@preview/fletcher:0.4.2" as fletcher: node, edge
#import fletcher.shapes: house, hexagon, diamond

#show link: set text(fill: blue)

#show: project.with(
  title: "实现一个自定义的tcp协议栈，并封装成一个简单的HTTP交互工具",
  authors: ((name: "徐一航", number: "2019212011"), (name: "李    睿", number: "2021211541"), (name: "张茗茜", number: "2021212306")),
  teacher: "张树壮",
  course: "网络协议分析实践",
)

#let code = raw => {
  text(font: "FiraMono Nerd Font Mono", raw)
}

= 实践内容

== 目标
实现一个自定义的tcp协议栈，并封装成一个简单的HTTP交互工具

+ 程序名称：程序名称：my_telnet

+ 支持如下参数：

  必选参数： 服务器IP地址，服务端口

  可选参数：要获取的文件名称（默认为主页）

+ 程序能够向指定的WEB服务器发送HTTP请求，并解析服务的应答，将应答进行打印，将获取的文件保存到本地。

  + 利用自定义协议栈实现TCP协议交互。需根据协议的格式自行构造请求数据包的传输层（TCP），并将数据包发送到网络上，并捕获响应包，并根据响应包进行回复，最终完成与服务端的TCP三次握手，建立连接。

  + 在TCP通信的基础上，实现HTTP协议交互。支持使用HTTP协议从指定的WEB服务器获取默认主页或指定的文件。

  + 数据下载完成后，完成TCP的四次挥手（可选功能）

\

== 原理
使用libpcap自定义协议栈完成TCP交互。
#pad(left: 2em)[
  - 在linux上，libpcap底层使用了raw socket来在链路层增加一个旁路处理，不干扰系统自身的网路协议栈的处理，对发送和接收的数据包通过Linux内核做过滤和缓冲处理，最后直接传递给上层应用程序。
  - 在windows上，npcap实现了 Win10 驱动程序，叫做 NPF（Netgroup Packet Filter），该驱动从 Win10 miniport 驱动获取网卡数据实现监控网络数据包的功能（Win10 使用 miniport 驱动控制网卡）。

]
JnetPcap是一个Java平台的pcap库绑定，提供了与原生库相同的API和一些方便的封装。我们选择使用JnetPcap来完成实验。

\

== 程序的主要功能
#pad(left: 2em)[
  - 解析命令行参数，获取服务器IP地址、服务端口。可以输入要获取的文件名称（默认为主页）或自定义HTTP请求
  - 与服务器建立TCP连接
  - 对服务器发送HTTP请求
  - 接收服务器的回复并打印到控制台
]  

== 使用方法

#figure(
  [```text
    usage: mytelnet <HOST> <port> [-h] [-p <path>]
     -h,--help          print this message
     -p,--path <path>   the path of the file to be get
    ```],
  caption: [使用 #code("--help") 参数的打印输出],
)

如果不输入 #text(font: "FiraMono Nerd Font Mono","-p") 参数，程序会等待并读取控制台输入的自定义文本，作为待发送的请求，就像原版telnet。不过本程序仅实现了实验要求，一次只能发送一个请求，不能像telnet一样不断地发送数据。

#pagebreak()

= 网络交互设计

所有不带有ACK标识的数据包的ack序列号都应该被忽略，包括第一次握手时发送的SYN包。实践上，常常将这样的包的ack设为0。

不带有ACK标识的数据包将不会被积极回复，服务器倾向于等待主动发送下一个包时顺便带上ACK标识来回复这种包。因此，除了第一次握手的SYN包，最好所有包都带上ACK标识。

#figure(image("1.png"), caption: "三次握手")

\

进行数据的发送和接收，在HTTP协议的视角，客户端发送了一个请求，服务端发送了一个回复。但在TCP协议的视角，上述两个包都需要对方发送ACK来确认收到。

#figure(
  grid(
    columns: (1fr, 1fr),
    image("2.png"),
    image("http.png"),
  ),
  caption: "从两个不同层面看发送数据",
)

#figure(image("4hui.png"), caption: "四次挥手")

完整的四次挥手流程需要客户端转换四次状态，在不考虑丢包和重传的情况下，我们只实现了两次状态转换。将#code("FIN_WAIT_1")与#code("FIN_WAIT_2")合并为#code("FIN_WAIT")状态，将#code("TIME_WAIT")状态去掉了。

还需注意，#code("FIN")标识也会消耗一个seq序列号。

#pagebreak()

= 主要流程

根据数据流图设计，程序可以分为3层：
+ 用户层：调用HTTP客户端来发送请求，获得回复，然后将回复打印出来。
+ 应用层：实现HTTP客户端，调用TCP协议栈来实现发送请求与接收回复的功能。还实现了解析HTTP应答，分割HTTP应答的状态、头字段、数据三个部分。
+ 传输层：调用JnetPcap提供的libpcap绑定来进行数据包的发送与接收，以此实现TCP协议栈并封装为类似Socket的API以供应用层使用。

#figure(image("3.png"), caption: "程序的数据流图")

\

== 用户层和应用层

这两层工作较少。应用层调用封装好的传输层API来实现HTTP客户端，并解析返回的HTTP Response，提取出相应的状态、头字段、数据信息。用户层读取命令行和控制台的输入，然后调用HTTP客户端来发送请求、获得答复。

\

== 传输层

本实验的主要内容。TCP协议栈维护了两个缓冲区，分别是发送缓冲区和接收缓冲区。

=== 缓冲区设计

缓冲区被设计为先进先出的队列，每次可以放入指定长度的数据包或数据。底层使用Java NIO库的ByteBuffer来存储数据。手动维护ByteBuffer实例的position、limit、capacity、mark字段来实现队列结构。

为每一个需要放入缓冲区的数据包或数据添加一个4字节的头部，代表数据包的长度。将数据包放入缓冲区时，先放入头部，再放入数据包本身。取出数据包时，先取4个字节，再根据这4字节代表的长度来决定需要取的数据包的长度。这样来保证每一次读和写都只对一个数据包进行操作。

缓冲区有两个状态，写入状态和读取状态。由于读取操作远比写入操作频繁，因此默认状态是待读取状态，切换成写入状态并写入完成后需要切换回待读取状态。

#figure(
  [```text
      先进行标记mark()，然后移动位置position()和上限limit()，将待读取状态变为待写入状态：
          +-----+-------------+---------------------------------------+
          |     |    Data     |             Unused Space              |
          +-----+-------------+---------------------------------------+
          ^     ^             ^                                       ^
          |     |             |                                       |
          0    mark       position                            capacity = limit

      写入完成：
          +-----+-------------+----------+----------------------------+
          |     |    Data     | New Data |       Unused Space         |
          +-----+-------------+----------+----------------------------+
          ^     ^                        ^                            ^
          |     |                        |                            |
          0    mark                  position                 capacity = limit

      复原位置position()和上限limit()：
          +-----+-------------+----------+----------------------------+
          |     |    Data     | New Data |       Unused Space         |
          +-----+-------------+----------+----------------------------+
          ^     ^                        ^                            ^
          |     |                        |                            |
          0   position                 limit                       capacity
    ```],
  caption: "缓冲区写入过程示例",
)

\

=== 发送缓冲区

启动一个线程来对其进行轮询。每当数据包被放入缓冲区，就将数据包发送到已连接的目标服务器。每次发送数据包时，数据包的窗口大小字段被设置为缓冲区的未使用空间大小。

发送完成后并不直接将已发送的数据从缓冲区内清除，而是等待收到相应的ACK后才将其清除。但是，本程序并没有实现重传机制，因此这样的设计仅在以后需要实现重传机制时，方便对程序进行扩展。

\

=== 接收缓冲区

启动一个线程来运行pcap抓包实例。当抓取到数据包时，根据数据包的标志位和序列号来决定需要进行的操作，并将数据包内包含的数据放入接收缓冲区。

每当用户调用 #text(font: "FiraMono Nerd Font Mono","recv()") 方法时，启动一个线程对接收缓冲区进行查询，若缓冲区为空，则暂时等待，直到有新的数据包被放入缓冲区时将其唤醒，然后再次查询缓冲区是否有数据。若缓冲区内有数据，则直接取走一段数据。

\

=== API

将协议栈封装为Socket类，提供以下方法：

对于未进行连接的Socket：
- #code("connect()") 与指定地址和端口的服务器进行连接，即进行三次握手。

对于已连接（调用过#code("connect()")）的Socket：
- #code("close()") 发起断开连接的请求，即发送FIN包。
- #code("send()") 发送指定数据，实现方法是将数据放入发送缓冲区中。如果数据长度大于MTU，可以自动进行分片。
- #code("recv()") 接收一个数据包，即从接收缓冲区中取一段数据并返回。如果超过一定时间后接收缓冲区中仍没有可取的数据，返回null。

调用 #code("close()") 后无法再调用 #code("send()")，但可以继续调用 #code("recv()")。

\

=== 数据包的构造和解析

对原始的字节数组进行简单封装，并提供一些方便的方法来构造数据包以及读取数据包的各种信息，如获得seq和ack序列号、窗口大小、标志位等。

#figure(
  [```text
         total length:[16..17]         seq:[38..41]  flags:[47]
                      ↓                       ↓       ↓
+----------------+-----------------------+-----------------------+--------
| Ethernet Frame |      IP Header        |      TCP Header       | Data ...
+----------------+-----------------------+-----------------------+--------
^                ^                       ^       ↑               ^
|                |                       |   ack:[42..45]        |
0                14                      34                      54

    ```],
  caption: "获得头部信息示例",
)

#pagebreak()

= 实验结果

使用以下命令运行程序：

```cmd
java --enable-preview --enable-native-access=ALL-UNNAMED -jar mytelnet 155.138.142.54 80 -p /

```

由于本项目使用Java编写，需要使用 #code("java -jar") 命令来启动程序。 

#code("--enable-preview") 参数用来开启Java的预览功能，jNetPcap库使用了Java 21的FFM (Foreign Function and Memory) API来调用Npcap库。我也使用Rust写了一个查询本地网络的路由器MAC地址的功能，导出为原生库在本项目中使用，也用了FFM API来简化调用步骤。

#code("--enable-native-access=ALL-UNNAMED") 参数用来关闭启用预览功能时JVM的警告。

\

以下是程序需要的参数，分别为服务器IP地址、服务端口、请求文件路径。

```
155.138.142.54 80 -p /
```



#figure(image("res.png"), caption: "运行结果")

\

使用 wireshark 工具，对运行本程序所产生的流量进行捕获。其中三次握手过程中服务器对SYN-ACK包进行了一次重传，这是因为本程序使用Pcap的数据包捕获功能来接收数据包，Pcap的抓包启动速度较慢，没有立刻收到服务器发来的SYN-ACK包并进行答复。

#figure(image("cap.png"), caption: "抓包结果")

\

此截图显示了程序与服务器的协议交互过程。
- 前4个包是双方进行握手（包含一次重传）
- 接下来四个包是HTTP请求与HTTP回应，以及对应的ACK
- 最后三个包是四次挥手，四次挥手只用了三个包是因为服务器应答时把对我方发送的FIN包的ACK确认与它自己要发的FIN包合并成了一个包。

一般来说，如果服务器收到客户端发来FIN时，并且没有剩下需要发送的数据，那么直接回复一个FIN-ACK包是正常的，可以省下一次发包。


#pagebreak()

= 附录

- 项目仓库：

#link("https://gitee.com/xixixiyu/mytelnet")[戏鱼 / mytelnet]

- 参考文章：

#link("https://slytechs-repos.github.io/jnetpcap-wrapper/apidocs/org.jnetpcap/module-summary.html")[jNetPcap文档]

#link("https://github.com/slytechs-repos/jnetpcap-wrapper/wiki")[jNetPcap Wiki]

#link("https://docs.oracle.com/en/java/javase/21/core/foreign-function-and-memory-api.html")[Foreign Function and Memory API]

#link("https://blog.csdn.net/mrwangwang/article/details/8537775")[IP头、TCP头、UDP头详解以及定义 -CSDN博客]

#link("https://commons.apache.org/proper/commons-cli/usage.html")[Apache Commons CLI – Using Apache Commons CLI]

#link("https://blog.csdn.net/weixin_45883188/article/details/123253175")[利用telnet命令发送HTTP请求报文 -CSDN博客]